package com.example.crawler.youtube.extractor;

import com.example.crawler.youtube.Extractor;
import com.example.crawler.youtube.exception.YoutubeException;
import com.example.crawler.youtube.model.ConvertResult;
import com.example.crawler.youtube.model.Format;
import com.example.crawler.youtube.model.YoutubeExtractorResult;
import com.example.crawler.youtube.model.YtFile;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.Ordered;
import org.springframework.stereotype.Component;

import javax.script.Invocable;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.util.*;
import java.util.Map.Entry;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

/**
 * @author fcxx1
 */
@Component
public class YouTubeSelfExtractor implements Extractor<YoutubeExtractorResult>, Ordered {
    private static final Logger logger = LoggerFactory.getLogger(YouTubeSelfExtractor.class);
    private final static boolean CACHING = true;
    private final static String CACHE_FILE_NAME = "decipher_js_funct";
    private static final String USER_AGENT =
            "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/40.0.2214.115 Safari/537.36";
    private static final Pattern patYouTubePageLink = Pattern.compile("(http|https)://(www\\.|m.|)youtube\\.com/watch\\?v=(.+?)( |\\z|&)");
    private static final Pattern patYouTubeShortLink = Pattern.compile("(http|https)://(www\\.|)youtu.be/(.+?)( |\\z|&)");
    private static final Pattern patStatusOk = Pattern.compile("status=ok(&|,|\\z)");
    private static final Pattern patItag = Pattern.compile("itag=([0-9]+?)(&|\\z)");
    private static final Pattern patEncSig = Pattern.compile("s=(.{10,}?)(\\\\\\\\u0026|\\z)");
    private static final Pattern patUrl = Pattern.compile("\"url\"\\s*:\\s*\"(.+?)\"");
    private static final Pattern patCipher = Pattern.compile("\"cipher\"\\s*:\\s*\"(.+?)\"");
    private static final Pattern patCipherUrl = Pattern.compile("url=(.+?)(\\\\\\\\u0026|\\z)");
    private static final Pattern patYtPlayer = Pattern
            .compile("<\\s*script\\s*>((.+?)jsbin\\\\/(player(_ias)?-(.+?).js)(.+?))</\\s*script\\s*>");
    private static final Pattern patVariableFunction = Pattern
            .compile("([{; =])([a-zA-Z$][a-zA-Z0-9$]{0,2})\\.([a-zA-Z$][a-zA-Z0-9$]{0,2})\\(");
    private static final Pattern patFunction = Pattern.compile("([{; =])([a-zA-Z$_][a-zA-Z0-9$]{0,2})\\(");
    private static final Pattern patDecryptionJsFile = Pattern.compile("jsbin\\\\/(player(_ias)?-(.+?).js)");
    private static final Pattern patSignatureDecFunction = Pattern
            .compile("\\b([\\w$]{2})\\s*=\\s*function\\((\\w+)\\)\\{\\s*\\2=\\s*\\2\\.split\\(\"\"\\)\\s*;");
    private static final Map<Integer, Format> FORMAT_MAP = new HashMap<>();
    private static String decipherJsFileName;
    private static String decipherFunctions;
    private static String decipherFunctionName;

    static {
        FORMAT_MAP.put(18, new Format(18, "mp4", 360, Format.VCodec.H264, Format.ACodec.AAC, 96, false));
        FORMAT_MAP.put(22, new Format(22, "mp4", 720, Format.VCodec.H264, Format.ACodec.AAC, 192, false));
    }

    @Value("${youtube.cache.js.path}")
    private String cacheDirPath;

    @Override
    public ConvertResult<YoutubeExtractorResult> convert(String youtubeVideoUrl) throws YoutubeException {
        ConvertResult<YoutubeExtractorResult> result = new ConvertResult<>();
        String videoId = getVideoId(youtubeVideoUrl);

        if (StringUtils.isBlank(videoId)) {
            throw new YoutubeException(youtubeVideoUrl, "videoId is null");
        }

        try {
            YoutubeExtractorResult convertResult = new YoutubeExtractorResult();
            boolean isConvertSuccess = getStreamUrls(videoId, convertResult);
            result.setSuccess(isConvertSuccess);
            result.setData(convertResult);
            return result;
        } catch (IOException e) {
            logger.error("IOException , videoId is {}", videoId, e);
            throw new YoutubeException(youtubeVideoUrl, e.getMessage());
        }
    }

    @Override
    public boolean enable() {
        return true;
    }

    @Override
    public String name() {
        return "self";
    }

    @Override
    public int getOrder() {
        return 0;
    }


    private String getVideoId(String ytUrl) {
        String videoId = null;
        if (ytUrl == null) {
            return null;
        }
        Matcher mat = patYouTubePageLink.matcher(ytUrl);
        if (mat.find()) {
            videoId = mat.group(3);
        } else {
            mat = patYouTubeShortLink.matcher(ytUrl);
            if (mat.find()) {
                videoId = mat.group(3);
            } else if (ytUrl.matches("\\p{Graph}+?")) {
                videoId = ytUrl;
            }
        }

        return videoId;
    }

    private boolean getStreamUrls(String videoId, YoutubeExtractorResult convertResult) throws IOException {
        logger.info("videoId is {}", videoId);

        String streamMap = getStreamMap(videoId);

        if (StringUtils.isBlank(streamMap)) {
            logger.error("stremMap is null!");
            return false;
        }

        boolean sigEnc = true, statusFail = false;
        if (!patCipher.matcher(streamMap).find()) {
            sigEnc = false;
            if (!patStatusOk.matcher(streamMap).find()) {
                statusFail = true;
            }
        }

        // Some videos are using a ciphered signature we need to get the
        // deciphering js-file from the youtubepage.
        if (sigEnc || statusFail) {
            // Get the video directly from the youtubepage
            if (CACHING && (decipherJsFileName == null || decipherFunctions == null || decipherFunctionName == null)) {
                readDecipherFunctFromCache();
            }

            streamMap = parseDecipherJsFileName(videoId);

        }

        Matcher mat;
        if (sigEnc) {
            mat = patCipher.matcher(streamMap);
        } else {
            mat = patUrl.matcher(streamMap);
        }

        LinkedHashMap<Integer, YtFile> ytFiles = new LinkedHashMap<Integer, YtFile>();
        LinkedHashMap<Integer, String> encSignatures = new LinkedHashMap<>();
        parseAvailableFormatVideo(mat, sigEnc, ytFiles, encSignatures);

        if (!encSignatures.isEmpty()) {
            String signature;
            Decipher decipher = new Decipher();
            if (decipherSignature(encSignatures, decipher)) {
            }
            signature = decipher.getDecipheredSignature();
            if (signature == null) {
                return false;
            } else {
                String[] sigs = signature.split("\n");

                Set<Entry<Integer, String>> entrySet = encSignatures.entrySet();
                List<Entry<Integer, String>> entryList = new ArrayList<Entry<Integer, String>>(entrySet);
                String targetUrl = null;
                for (int i = 0; i < encSignatures.size() && i < sigs.length; i++) {
                    int key = entryList.get(i).getKey();

                    String url = ytFiles.get(key).getUrl();
                    url += "&sig=" + sigs[i];
                    if (key == 18) {
                        targetUrl = url;
                        break;
                    }
                    targetUrl = url;
                }

                if (StringUtils.isNotBlank(targetUrl)) {
                    convertResult.setTargetUrl(targetUrl);
                    return true;
                }
            }
        } else if (!ytFiles.isEmpty()) {
            final LinkedHashMap<Integer, YtFile> sortedYtFiles = ytFiles.entrySet().stream().sorted(Entry.comparingByKey())
                    .collect(Collectors.toMap(Entry::getKey, Entry::getValue,
                            (oldValue, newValue) -> oldValue, LinkedHashMap::new));
            final Set<Entry<Integer, YtFile>> entries = sortedYtFiles.entrySet();
            for (Entry<Integer, YtFile> entry : entries) {
                convertResult.setTargetUrl(entry.getValue().getUrl());
                return true;
            }


        }
        return false;
    }

    private void parseAvailableFormatVideo(Matcher mat, boolean sigEnc, LinkedHashMap<Integer, YtFile> ytFiles, LinkedHashMap<Integer, String> encSignatures) throws UnsupportedEncodingException {
        while (mat.find()) {
            String sig = null;
            String url;
            if (sigEnc) {
                String cipher = mat.group(1);
                Matcher mat2 = patCipherUrl.matcher(cipher);
                if (mat2.find()) {
                    url = URLDecoder.decode(mat2.group(1), "UTF-8");
                    mat2 = patEncSig.matcher(cipher);
                    if (mat2.find()) {
                        sig = URLDecoder.decode(mat2.group(1), "UTF-8");
                    } else {
                        continue;
                    }
                } else {
                    continue;
                }
            } else {
                url = mat.group(1);
            }

            Matcher mat2 = patItag.matcher(url);
            if (!mat2.find()) {
                continue;
            }

            int itag = Integer.parseInt(mat2.group(1));

            if (FORMAT_MAP.get(itag) == null) {
                continue;
            }

            // Unsupported
            if (url.contains("&source=yt_otf&")) {
                continue;
            }

            if (sig != null) {
                encSignatures.put(itag, sig);
            }

            Format format = FORMAT_MAP.get(itag);
            YtFile newVideo = new YtFile(format, url);
            ytFiles.put(itag, newVideo);
        }
    }

    private String getStreamMap(String videoId) throws IOException {
        String streamMap = null;
        try {
            String ytInfoUrl =
                    "https://www.youtube.com/get_video_info?video_id=" + videoId + "&eurl="
                            + URLEncoder.encode("https://youtube.googleapis.com/v/" + videoId, "UTF-8");
            streamMap = getInputStreamFirstLine(ytInfoUrl);

            streamMap = URLDecoder.decode(streamMap, "UTF-8");
            streamMap = streamMap.replace("\\u0026", "&");
        } catch (IOException e) {
            logger.error("open url connection error, videoId is {}", videoId, e);
        }

        return streamMap;
    }

    private String parseDecipherJsFileName(String videoId) throws IOException {
        BufferedReader reader = null;
        HttpURLConnection urlConnection = null;
        Matcher mat;
        String streamMap = null;
        try {
            URL getUrl = new URL("https://youtube.com/watch?v=" + videoId);
            urlConnection = (HttpURLConnection) getUrl.openConnection();
            urlConnection.setRequestProperty("User-Agent", USER_AGENT);
            reader = new BufferedReader(new InputStreamReader(urlConnection.getInputStream()));
            String line;
            while ((line = reader.readLine()) != null) {
                mat = patYtPlayer.matcher(line);
                if (mat.find()) {
                    streamMap = line.replace("\\\"", "\"");
                    break;
                }
            }
        } catch (IOException e) {
            logger.error("open url connection error, videoId is {}", videoId, e);
        } finally {
            if (reader != null) {
                reader.close();
            }
            urlConnection.disconnect();
        }

        if (StringUtils.isNotBlank(streamMap)) {
            mat = patDecryptionJsFile.matcher(streamMap);
            if (mat.find()) {
                String curJsFileName = mat.group(1).replace("\\/", "/");
                if (mat.group(2) != null) {
                    curJsFileName.replace(mat.group(2), "");
                }
                if (decipherJsFileName == null || !decipherJsFileName.equals(curJsFileName)) {
                    decipherFunctions = null;
                    decipherFunctionName = null;
                }
                decipherJsFileName = curJsFileName;
            }
        }
        return streamMap;
    }

    private String getInputStreamFirstLine(String ytInfoUrl) throws IOException {
        BufferedReader reader = null;
        HttpURLConnection urlConnection = null;
        String streamLine = null;
        try {
            URL getUrl = new URL(ytInfoUrl);
            urlConnection = (HttpURLConnection) getUrl.openConnection();
            urlConnection.setRequestProperty("User-Agent", USER_AGENT);

            reader = new BufferedReader(new InputStreamReader(urlConnection.getInputStream()));
            streamLine = reader.readLine();

        } catch (IOException e) {
            logger.error("open url connection error, ytInfoUrl is {}", ytInfoUrl, e);
        } finally {
            if (reader != null) {
                reader.close();
            }
            urlConnection.disconnect();
        }

        return streamLine;
    }

    private boolean decipherSignature(final LinkedHashMap<Integer, String> encSignatures, Decipher decipher) throws IOException {
        // Assume the functions don't change that much
        if (StringUtils.isNotBlank(decipherJsFileName) && (decipherFunctionName == null || decipherFunctions == null)) {
            String decipherFunctUrl = "https://s.ytimg.com/yts/jsbin/" + decipherJsFileName;
            String javascriptFile = getDecipherJsFile(decipherFunctUrl);
            if (StringUtils.isBlank(javascriptFile)) {
                logger.error("decipher js file error! decipherFunctUrl is {}", decipherFunctUrl);
                throw new YoutubeException("解析失败");
            }
            Matcher mat = patSignatureDecFunction.matcher(javascriptFile);
            if (mat.find()) {
                decipherFunctionName = mat.group(1);

                Pattern patMainVariable =
                        Pattern.compile("(var |\\s|,|;)" + decipherFunctionName.replace("$", "\\$") + "(=function\\((.{1,3})\\)\\{)");

                String mainDecipherFunct;

                mat = patMainVariable.matcher(javascriptFile);
                if (mat.find()) {
                    mainDecipherFunct = "var " + decipherFunctionName + mat.group(2);
                } else {
                    Pattern patMainFunction = Pattern.compile("function " + decipherFunctionName.replace("$", "\\$") + "(\\((.{1,3})\\)\\{)");
                    mat = patMainFunction.matcher(javascriptFile);
                    if (!mat.find()) {
                        return false;
                    }
                    mainDecipherFunct = "function " + decipherFunctionName + mat.group(2);
                }

                int startIndex = mat.end();

                for (int braces = 1, i = startIndex; i < javascriptFile.length(); i++) {
                    if (braces == 0 && startIndex + 5 < i) {
                        mainDecipherFunct += javascriptFile.substring(startIndex, i) + ";";
                        break;
                    }
                    if (javascriptFile.charAt(i) == '{') {
                        braces++;
                    } else if (javascriptFile.charAt(i) == '}') {
                        braces--;
                    }
                }
                decipherFunctions = mainDecipherFunct;
                // Search the main function for extra functions and variables
                // needed for deciphering
                // Search for variables
                mat = patVariableFunction.matcher(mainDecipherFunct);
                while (mat.find()) {
                    String variableDef = "var " + mat.group(2) + "={";
                    if (decipherFunctions.contains(variableDef)) {
                        continue;
                    }
                    startIndex = javascriptFile.indexOf(variableDef) + variableDef.length();
                    for (int braces = 1, i = startIndex; i < javascriptFile.length(); i++) {
                        if (braces == 0) {
                            decipherFunctions += variableDef + javascriptFile.substring(startIndex, i) + ";";
                            break;
                        }
                        if (javascriptFile.charAt(i) == '{') {
                            braces++;
                        } else if (javascriptFile.charAt(i) == '}') {
                            braces--;
                        }
                    }
                }
                // Search for functions
                mat = patFunction.matcher(mainDecipherFunct);
                while (mat.find()) {
                    String functionDef = "function " + mat.group(2) + "(";
                    if (decipherFunctions.contains(functionDef)) {
                        continue;
                    }
                    startIndex = javascriptFile.indexOf(functionDef) + functionDef.length();
                    for (int braces = 0, i = startIndex; i < javascriptFile.length(); i++) {
                        if (braces == 0 && startIndex + 5 < i) {
                            decipherFunctions += functionDef + javascriptFile.substring(startIndex, i) + ";";
                            break;
                        }
                        if (javascriptFile.charAt(i) == '{') {
                            braces++;
                        } else if (javascriptFile.charAt(i) == '}') {
                            braces--;
                        }
                    }
                }

                String decipheredSignature = decipherViaWebView(encSignatures);
                decipher.setDecipheredSignature(decipheredSignature);
                if (CACHING) {
                    writeDeciperFunctToChache();
                }
            } else {
                return false;
            }
        } else {
            String decipheredSignature = decipherViaWebView(encSignatures);
            decipher.setDecipheredSignature(decipheredSignature);
        }
        return true;
    }

    private String getDecipherJsFile(String decipherFunctUrl) throws IOException {
        String javascriptFile = null;
        HttpURLConnection urlConnection = null;
        try {
            URL url = new URL(decipherFunctUrl);
            urlConnection = (HttpURLConnection) url.openConnection();
            urlConnection.setRequestProperty("User-Agent", USER_AGENT);
            try (BufferedReader reader = new BufferedReader(new InputStreamReader(urlConnection.getInputStream()))) {
                StringBuilder sb = new StringBuilder("");
                String line;
                while ((line = reader.readLine()) != null) {
                    sb.append(line);
                    sb.append(" ");
                }
                javascriptFile = sb.toString();
            }
        } catch (IOException e) {
            logger.error("open url connection error, url is {}", decipherFunctUrl, e);
        } finally {
            if (urlConnection != null) {
                urlConnection.disconnect();
            }
        }

        return javascriptFile;
    }

    private void readDecipherFunctFromCache() {
        File cacheFile = new File(cacheDirPath + "/" + CACHE_FILE_NAME);
        // The cached functions are valid for 2 weeks
        if (cacheFile.exists() && (System.currentTimeMillis() - cacheFile.lastModified()) < 1209600000) {
            try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(cacheFile), "UTF-8"))) {
                decipherJsFileName = reader.readLine();
                decipherFunctionName = reader.readLine();
                decipherFunctions = reader.readLine();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    private void writeDeciperFunctToChache() throws IOException {
        try {
            File cacheFile = new File(cacheDirPath + "/" + CACHE_FILE_NAME);
            if (!cacheFile.exists()) {
                cacheFile.createNewFile();
            }
            try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(cacheFile), "UTF-8"))) {
                writer.write(decipherJsFileName + "\n");
                writer.write(decipherFunctionName + "\n");
                writer.write(decipherFunctions);
            }
        } catch (Exception e) {
            logger.error("write cache js file error!", e);
        }
    }

    private String decipherViaWebView(final LinkedHashMap<Integer, String> encSignatures) {

        final StringBuilder stb = new StringBuilder(decipherFunctions + " function decipher(");
        stb.append("){return ");
        Set<Entry<Integer, String>> entrySet = encSignatures.entrySet();
        int i = 0;
        for (Entry<Integer, String> entry : entrySet) {
            Integer key = entry.getKey();
            if (i < encSignatures.size() - 1) {
                stb.append(decipherFunctionName).append("('").append(encSignatures.get(key)).append("')+\"\\n\"+");
            } else {
                stb.append(decipherFunctionName).append("('").append(encSignatures.get(key)).append("')");
            }

            i = i + 1;
        }

        stb.append("};decipher();");

        String scriptResult = "";

        ScriptEngine engine = new ScriptEngineManager().getEngineByName("nashorn");
        try {
            engine.eval(new StringReader(stb.toString()));

            Invocable invocable = (Invocable) engine;

            scriptResult = (String) invocable.invokeFunction("decipher");

//      return scriptResult;

        } catch (ScriptException e) {
            System.out.println("Error executing script: " + e.getMessage() + " script:[" + stb.toString() + "]");
        } catch (NoSuchMethodException e) {
            System.out.println("Error executing script,No Such Method: " + e.getMessage() + " script:[" + stb.toString() + "]");
        }

        return scriptResult;
    }


    public static class Decipher implements Serializable {
        private static final long serialVersionUID = 3888119472235112637L;
        private String decipheredSignature;

        public String getDecipheredSignature() {
            return decipheredSignature;
        }

        public void setDecipheredSignature(String decipheredSignature) {
            this.decipheredSignature = decipheredSignature;
        }


    }
}
